ποΈGitΠ―ΡΠ°ποΈ
Commit ae7c18d30c2290a37c98ac1fee07b8fb04e068ac
Parents : 78ba069
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-22T12:17:26-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-22T17:17:26Z
fix(nav): register /wifi-provision and /discovery as https App Links (#6365)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changes
4 files changed, 146 insertions(+), 15 deletions(-)
Diff
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 6659cbeb79..999c0e7bf4 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -263,6 +263,8 @@
<data android:pathPrefix="/settings" />
<data android:pathPrefix="/channels" />
<data android:pathPrefix="/firmware" />
+ <data android:pathPrefix="/wifi-provision" />
+ <data android:pathPrefix="/discovery" />
</intent-filter>
<intent-filter>
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/DeepLinkManifestConsistencyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/DeepLinkManifestConsistencyTest.kt
new file mode 100644
index 0000000000..a841d3da3a
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/DeepLinkManifestConsistencyTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.ui
+
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.navigation.DeepLinkRouter
+import org.w3c.dom.Element
+import java.io.File
+import javax.xml.parsers.DocumentBuilderFactory
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Guards against drift between [DeepLinkRouter] and the https App Links intent-filter (`android:autoVerify="true"`,
+ * host `meshtastic.org`) in `androidApp/src/main/AndroidManifest.xml`.
+ *
+ * Every top-level path segment routed by [DeepLinkRouter.route] must be declared as an `android:pathPrefix` in that
+ * filter β otherwise `https://meshtastic.org/{path}` links open in the browser instead of the app, even though the
+ * `meshtastic://` scheme works. The segments come straight from [DeepLinkRouter.topLevelPathSegments], the set
+ * [DeepLinkRouter.route] gates its dispatch on, so a new router segment fails here until the manifest declares it.
+ */
+class DeepLinkManifestConsistencyTest {
+
+ @Test
+ fun `every canonical segment is actually routed by DeepLinkRouter`() {
+ DeepLinkRouter.topLevelPathSegments.forEach { segment ->
+ assertNotNull(
+ DeepLinkRouter.route(CommonUri.parse("https://meshtastic.org/$segment")),
+ "DeepLinkRouter.topLevelPathSegments lists /$segment but route() has no branch for it β " +
+ "add the branch or remove the segment from the set and the manifest",
+ )
+ }
+ }
+
+ @Test
+ fun `app links intent filter declares a pathPrefix for every routed segment`() {
+ val prefixes = appLinkPathPrefixes()
+ DeepLinkRouter.topLevelPathSegments.forEach { segment ->
+ assertTrue(
+ "/$segment" in prefixes,
+ "AndroidManifest.xml autoVerify filter is missing <data android:pathPrefix=\"/$segment\" /> β " +
+ "https://meshtastic.org/$segment will open in the browser instead of the app",
+ )
+ }
+ }
+
+ /** Collects the pathPrefix values of the autoVerify (App Links) intent-filter for meshtastic.org. */
+ private fun appLinkPathPrefixes(): Set<String> {
+ val manifest = manifestFile()
+ val factory =
+ DocumentBuilderFactory.newInstance().apply {
+ // Harden against XXE even though we only parse our own manifest.
+ setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
+ setFeature("http://xml.org/sax/features/external-general-entities", false)
+ setFeature("http://xml.org/sax/features/external-parameter-entities", false)
+ isXIncludeAware = false
+ isExpandEntityReferences = false
+ }
+ val document = factory.newDocumentBuilder().parse(manifest)
+ val filters = document.getElementsByTagName("intent-filter")
+ val prefixes = mutableSetOf<String>()
+ for (i in 0 until filters.length) {
+ val filter = filters.item(i) as Element
+ if (filter.getAttribute("android:autoVerify") != "true") continue
+ val dataElements = filter.getElementsByTagName("data")
+ val attrs = (0 until dataElements.length).map { dataElements.item(it) as Element }
+ if (attrs.none { it.getAttribute("android:host") == "meshtastic.org" }) continue
+ if (attrs.none { it.getAttribute("android:scheme") == "https" }) continue
+ attrs.mapNotNullTo(prefixes) { it.getAttribute("android:pathPrefix").ifEmpty { null } }
+ }
+ if (prefixes.isEmpty()) fail("No https App Links intent-filter for meshtastic.org found in ${manifest.path}")
+ return prefixes
+ }
+
+ private fun manifestFile(): File = listOf("src/main/AndroidManifest.xml", "androidApp/src/main/AndroidManifest.xml")
+ .map(::File)
+ .firstOrNull(File::exists)
+ ?: fail("Could not locate AndroidManifest.xml from working directory ${File(".").absolutePath}")
+}
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
index dac31aaae2..a33d7d9227 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt
@@ -42,6 +42,35 @@ import org.meshtastic.core.common.util.CommonUri
* `address=n` disconnects instead of connecting.
*/
object DeepLinkRouter {
+ /**
+ * Canonical set of top-level path segments this router dispatches on. [route] refuses segments outside this set, so
+ * a new `when` branch stays dead (and its feature tests fail) until its segment is added here. Every entry must
+ * also be declared as an `android:pathPrefix` in the https App Links intent-filter in
+ * `androidApp/src/main/AndroidManifest.xml` β DeepLinkManifestConsistencyTest (androidApp unit tests) enforces that
+ * directly from this set.
+ */
+ val topLevelPathSegments: Set<String> =
+ setOf(
+ "share",
+ "messages",
+ "quickchat",
+ "connections",
+ "discovery",
+ "map",
+ "nodes",
+ "settings",
+ "channels",
+ "firmware",
+ "wifi-provision",
+ )
+
+ /**
+ * Legacy import path segments (`/e/` = channel set, `/v/` = shared contact, matched case-insensitively). These are
+ * handled by the `dispatchMeshtasticUri` fallback rather than this router, so [route] returns null for them without
+ * logging a warning.
+ */
+ private val legacyImportSegments = setOf("e", "v")
+
/**
* Synthesizes a backstack list from an incoming Meshtastic URI.
*
@@ -50,13 +79,17 @@ object DeepLinkRouter {
*/
fun route(uri: CommonUri): List<NavKey>? {
val pathSegments = uri.pathSegments.filter { it.isNotBlank() }
+ val firstSegment = pathSegments.firstOrNull()?.lowercase()
- if (pathSegments.isEmpty()) {
+ if (firstSegment !in topLevelPathSegments) {
+ // /e/ and /v/ are channel-set/contact import links, not navigation routes: returning null here lets
+ // callers fall back to dispatchMeshtasticUri (see UIViewModel.handleDeepLink), so don't warn on them.
+ if (firstSegment != null && firstSegment !in legacyImportSegments) {
+ Logger.w { "Unrecognized deep link segment: $firstSegment" }
+ }
return null
}
- val firstSegment = pathSegments[0].lowercase()
-
return when (firstSegment) {
"share",
"messages",
@@ -79,10 +112,8 @@ object DeepLinkRouter {
"wifi-provision" -> routeWifiProvision(uri)
- else -> {
- Logger.w { "Unrecognized deep link segment: $firstSegment" }
- null
- }
+ // Unreachable: gated on topLevelPathSegments above.
+ else -> null
}
}
diff --git a/docs/en/developer/navigation-and-deep-links.md b/docs/en/developer/navigation-and-deep-links.md
index 3cc80f7565..62614e5037 100644
--- a/docs/en/developer/navigation-and-deep-links.md
+++ b/docs/en/developer/navigation-and-deep-links.md
@@ -47,24 +47,27 @@ sealed interface SettingsRoute : Route {
### URI Format
-Both forms resolve through the same `DeepLinkRouter`:
+Both forms resolve through the same `DeepLinkRouter`, so any path below works with either scheme:
```text
meshtastic://meshtastic/{path}
https://meshtastic.org/{path} # App Link, android:autoVerify β also opens in-app on a real device/adb
```
-The `meshtastic://` scheme accepts every path below. The `https://` App Link only covers the path
-prefixes declared in the manifest intent-filter (`/share`, `/connections`, `/map`, `/messages`,
-`/quickchat`, `/nodes`, `/settings`, `/channels`, `/firmware`) β notably `/wifi-provision` and
-`/discovery` currently resolve only via the custom scheme.
-
`adb shell am start -a android.intent.action.VIEW -d "meshtastic://meshtastic/{path}"` is the fastest way to
trigger any route below from a shell or automation script without touching the UI.
-**Source of truth:** the always-current list of segments lives in
+For the `https` form to open in-app, each top-level path segment must also be declared as an
+`android:pathPrefix` in the `android:autoVerify` intent-filter in `androidApp/src/main/AndroidManifest.xml` β
+otherwise the link opens in the browser. Adding a new top-level route therefore takes three steps: add the
+segment to `DeepLinkRouter.topLevelPathSegments` (the router refuses to dispatch segments outside that set),
+add its `when` branch in `DeepLinkRouter.route()`, and add the matching `pathPrefix` to the manifest.
+`DeepLinkManifestConsistencyTest` (androidApp unit tests) checks the manifest against the set, so a missing
+manifest entry fails CI.
+
+**Source of truth:** the always-current list of top-level segments is `topLevelPathSegments` in
[`DeepLinkRouter`](https://github.com/meshtastic/Meshtastic-Android/blob/main/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt)
-β the `route()` `when` block plus its helper maps (`settingsSubRoutes`, `nodeDetailSubRoutes`);
+β sub-paths live in the `route()` `when` block plus its helper maps (`settingsSubRoutes`, `nodeDetailSubRoutes`);
the class-level KDoc is illustrative, not exhaustive. It also exists as executable spec in
[`DeepLinkRouterTest.kt`](https://github.com/meshtastic/Meshtastic-Android/blob/main/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt).
The table below is a snapshot for quick reference β check those two files if it looks out of date.
Served by rngit 1.5.2 - Generated in 0.07s